iT邦幫忙

2023 iThome 鐵人賽

DAY 5
0
Software Development

Python十翼:與未來的自己對話系列 第 5

[Day05] 次翼 - Decorator:@func to func

  • 分享至 

  • xImage
  •  

次翼大綱

decoratormeta programming的技巧之一,可以改變被其裝飾obj之行為。

由於decorator可以用functionclass兩種方法來實作,我們定義以
function實作的,稱為decorator function;而以class實作的,稱為decorator class

又由於decorator可以裝飾在functionclass上,所以可能會有以下四種組合:

  1. decorator function裝飾於function上([Day05]內容)。
  2. decorator function裝飾於class上([Day07]內容)。
  3. decorator class裝飾於function上([Day06]內容)。
  4. decorator class裝飾於class上。

其中第四種decorator class裝飾於class上的使用情況,我們沒有想到適合的實例,所以決定只針對前三種decorator進行筆記。

本日接下來內容,會以decorator來作為decorator function的簡稱。

核心概念

decorator的核心概念為接受一個function,從中做一些操作,最後返回一個function。一般來說,返回的function會接收與原function相同的參數,並返回相同的結果,但卻能具有decorator額外賦予的功能(註1)。

原理

decorator的原理,可以用# 01來說明。

# 01
def dec(func):
    return func


def my_func():
    pass


if __name__ == '__main__':
    orig_func_id = id(my_func)
    my_func = dec(my_func)
    deced_func_id = id(my_func)
    print(orig_func_id == deced_func_id)  # True
  • 定義一個decorator,名為dec
  • 定義一個待被裝飾的function,名為my_func
  • 定義一個變數,其名與my_func相同,來接收dec(my_func)的回傳值。

dec其實只是接收了一個變數my_func,再將其返回。比較特別的是,我們新定義了一個與被裝飾的my_func同名的變數來接收其回傳值。在這個範例裡,新變數my_func其實就是一開始定義的my_func function,這可以透過觀察前後兩個my_funcid確認。

語法糖

由於經常需要使用被裝飾的function名作為新變數名,Python提供了下列的@dec作為語法糖(syntax suger),來幫助大家快速完成這類操作。因此,# 01可以改寫為# 02這個較常見的寫法:

# 02
def dec(func):
    return func


@dec
def my_func():
    pass

基本型態

基本型態1

觀察# 02,會發現my_func沒有接受任何參數,這使得它的應用有些局限。我們通常會希望function能根據不同的參數,給出相對應的結果。

根據上述期望,可以寫出# 03

# 03
def dec(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper


@dec
def my_func(*args: int, **kwargs: int) -> int:
    pass
  • 定義一個decorator,名為dec。其接收一個function,但卻返回另一個於內部建立的wrapper function
  • 定義一個被dec裝飾的function,名為my_func,其可接受*args**kwargs

藉由decoratormy_func已經從原先的my_func變成wrapper這個function了。由於wrapper與原先的my_func接收相同的參數(即*args**kwargs),所以裝飾前後,my_func的呼叫方式是一致的。

當我們呼叫my_func時,實際上是在呼叫wrapper。舉例來說,此時的my_func(1, 2),相當於呼叫wrapper(1, 2)。而wrapper則返回原先傳入的my_func搭配上args = (1, 2),kwargs = {}這些參數的計算結果。

# 03而言,不管my_func有沒有被dec裝飾,其結果是一樣的。但decorator可以視為一個hook,讓我們可以於函數呼叫前或後,進行一些操作。

基本型態2(加上functools.wraps

如果仔細觀察一下my_func及其相關的metadata

my_func=<function dec.<locals>.wrapper at 0x000001DBF55C4FE0>
my_func.__module__='__main__'
my_func.__name__='wrapper'
my_func.__doc__=None
my_func.__qualname__='dec.<locals>.wrapper'
my_func.__annotations__={}
my_func.__dict__={}

會發現my_func顯示為wrapper,且其metadata也不符合我們的預期,我們會希望即使是被裝飾過的function,其metadata還是可以保留。

Python內建的functools.wraps可以作為decorator使用(註2),幫助我們更新正確的metadatawrapper function,如# 04所示。

# 04
from functools import wraps


def dec(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper


@dec
def my_func(*args: int, **kwargs: int) -> int:
    pass
my_func=<function my_func at 0x00000239ECC14FE0>
my_func.__module__='__main__'
my_func.__name__='my_func'
my_func.__doc__=None
my_func.__qualname__='my_func'
my_func.__annotations__={'args': <class 'int'>, 'kwargs': <class 'int'>, 'return': <class 'int'>}
my_func.__dict__={'__wrapped__': <function my_func at 0x00000239ECC16660>}

實例說明

一個常用的情況是透過decoratorlogging一些資訊,如# 05:

# 05
import logging
from functools import wraps


def log(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        logging.info(f'wrapper is called, {func=}, {args=}, {kwargs=}')
        return func(*args, **kwargs)
    return wrapper


@log
def add(a: int, b: int) -> int:
    return a + b


if __name__ == '__main__':
    logging.basicConfig(level=logging.INFO)
    print(add(1, 2))  # 3

wrapper中,我們加了一行logging.info來協助記錄每次wrapper被呼叫時,其實際使用的funcargskwargs

此時若呼叫add,會顯示logging的記錄,且返回正確答案。

INFO:root:wrapper is called, func=<function add at 0x000001E918BC6660>, args=(1, 2), kwargs={}
3

decorator factory(本身可接收參數)

由於我們希望裝飾前後的函數,會接收相同的參數,如此較為方便使用。所以當想要傳入一些自訂的參數或是flag時,可以將其作為decorator本身的參數傳入。

舉例來說,當我們想要有一個flag來控制這個decorator是否要logging,可以寫成# 06

# 06
import logging
from functools import wraps


def log(to_log=True):
    def dec(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            if to_log:
                logging.info(f'wrapper is called, {func=}, {args=}, {kwargs=}')
            return func(*args, **kwargs)
        return wrapper
    return dec


@log(to_log=False)
def add(a: int, b: int) -> int:
    """Take two integers and return their sum."""
    return a + b

log這個function內又包了兩層function,乍看好像有點複雜,讓我們逐層拆解看看。

  • 首先,log接收一個參數,名為to_log,其預設值為True,並會返回內部第一層的dec function。此時,對於被裝飾的function來說,相當於我們直接將dec裝飾其上,並將給定的to_log往下傳遞。
  • 接下來,我們就像回到基本型態dec接收一個func(即add),並返回wrapper function
  • 最後,於wrapper內,實作大部份邏輯。此時,於wrapper中我們擁有:
    • 第一層接收的to_log參數。
    • 第二層接收的被裝飾的add function
    • 第三層接收的add參數(*args**kwargs)。此時,我們利用to_log來決定是否進行logging,並將呼叫add(*args, **kwargs)的結果作為wrapper的返回值。

這樣的pattern,可以稱為decorator factory,因為實際上dec才是decorator,第一層的參數只是為了提供真正的decorator一些額外的訊息。

實例說明

# 07為一個decorator factory實例。

# 07
import logging
from functools import wraps
from numbers import Real
from typing import get_type_hints


def log(*, to_log=True, validate_input=True):
    def dec(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            if to_log:
                logging.info(
                    f' `wrapper` is called, {func=}, {args=}, {kwargs=}')
            if validate_input:
                n = len(args) + len(kwargs)
                type_hints = get_type_hints(func)
                if n and n+1 > len(type_hints):  # return is included in type_hints
                    if to_log:
                        logging.error(
                            f'Annotations={type_hints}, {args=}, {kwargs=}')
                    raise TypeError('Some annotations might be missing.')

                if args and not all(isinstance(arg, type_)
                                    for arg, type_ in zip(args, type_hints.values())):
                    if to_log:
                        logging.error(
                            f'Annotations={type_hints}, {args=}, {kwargs=}')
                    raise TypeError(
                        f'Possible incorrect type assignment in {args=}')

                if kwargs and not all(isinstance(kw_value, type_)
                                      for name, type_ in type_hints.items()
                                      if (kw_value := kwargs.get(name))):
                    if to_log:
                        logging.error(
                            f'Annotations={type_hints}, {args=}, {kwargs=}')
                    raise TypeError(
                        f'Possible incorrect type assignment in {kwargs=}')

            result = func(*args, **kwargs)

            if validate_input:
                expected_return_type = type_hints['return']
                if not isinstance(result, expected_return_type):
                    logging.warning(
                        f' Return value: {result}(type={type(result)}) is not an ' +
                        f'instance of {expected_return_type}')
            if to_log:
                logging.info(' `wrapper` is finished.')
            return result
        return wrapper
    return dec


@log(to_log=True, validate_input=True)
def add(a: Real, b: Real) -> Real:
    """Take two reals and return their sum."""
    return a + b


if __name__ == '__main__':
    logging.basicConfig(level=logging.DEBUG)
    r = add(1.2, b=3.2)
    print(r, type(r))  # 3.5, float
INFO:root: `wrapper` is called, func=<function add at 0x000002D40BB3CFE0>, args=(1.2,), kwargs={'b': 2.3}
INFO:root: `wrapper` is finished.
3.5 <class 'float'>
  • 第一層log接收兩個keyword-only argumentsto_logvalidate_input,預設皆為True。當to_logTrue時,會呼叫logging模組,記錄不同等級的資訊,如logging.infologging.warninglogging.error等等。當validate_inputTrue,會確認給定的argskwargs及計算結果與type hint是否相符(註3)。最後會回傳第二層的dec function
  • 第二層的dec function 會接收被裝飾的function為參數,即func。最後會回傳第三層的wrapper function
  • 第三層的wrapper會接收使用者呼叫func的參數,即*args**kwargs。我們將functools.wraps裝飾於wrapper上,讓其幫忙將funcmetadata更新給wrapper
  • 接下來我們對wrapper逐段說明。
    • 如果to_logTure,則呼叫logging.info記錄wrapper被呼叫。
    • 如果validate_inputTrue,將會分別確認三件事。如果其中任何一件事不符合,則會呼叫logging.errorraise TypeError
      • 首先,我們呼叫types.get_type_hints來取得functype hint,接著確認是不是每個參數與回傳值都有給定annotation
      • 再來,由於使用者呼叫add時,可能會使用positionalkeyword兩種型態傳遞參數,所以要分別確認argskwargs
      • 由於args必定會出現於kwargs之前,再加上Python於3.7後,其dict會維持插入時的順序(3.6屬於非正式支援),所以我們可以使用zip(args, type_hints.values())搭配isinstance來確認args內的每個obj都是type_hints內所描述typeinstance註4)。
      • 針對kwargs,我們可以試著尋找kwargstype_hints的共同key,並搭配isinstance確認這些共同keyvalue皆為type_hints內所描述型態的instance
    • result = func(*args, **kwargs)func真正被呼叫,進行計算的地方。
    • 如果validate_inputTure,則檢查result是否為給定type hintinstance。此時,即便是未通過檢查,也僅呼叫logging.warning,而不raise TypeError。這是一個折衷的寫法,一般來說如果result能順利計算完畢,相比於raise Exception,我們會傾向回傳所計算結果,但以warning提醒,讓使用者自己確認,究竟是給錯型態,又或者結果真的不符預期。
    • 如果to_logTure,呼叫logging.info記錄wrapper執行完畢。
    • 最後回傳result

常用型態(@dec | @dec())

如果依照上述的寫法,我們需要呼叫decorator factory,才能取得真正的decorator,也就是說即使我們沒有要修改decorator factory的預設值,我們仍然需要使用@log()的語法才能裝飾function,使用起來有點麻煩。

我們的目標是,希望在沒有要修改decorator factory預設值時,能夠僅使用@log,且@log@log()是同義的。

要能夠達成目標的關鍵是,如何區分第一個參數是decorator factory的參數,還是已經是要被裝飾的function。這可以仰仗Python的positional-only argumentkeyword-only argument。我們可以指定第一個參數一定要是positional-only(預設值為None),而第二個參數以後一定要是keyword-only

如此一來,我們可以判斷當第一個參數為None時,是使用了@log()這種語法;而當其不為None時,表示是接收了要被裝飾的function,即使用了@log這種語法。

decorator function的寫法,除了可以裝飾於function上,也適用於class中的function

以下提供兩種常見的寫法:

方法1

# 08
import logging
from functools import wraps


def log(func=None, /, *, to_log=True):
    def dec(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            if to_log:
                logging.info(f'wrapper is called, {func=}, {args=}, {kwargs=}')
            return func(*args, **kwargs)
        return wrapper

    if func is None:
        return dec
    return dec(func)


@log()
def add1(a: int, b: int) -> int:
    """Take two integers and return their sum."""
    return a + b


@log
def add2(a: int, b: int) -> int:
    """Take two integers and return their sum."""
    return a + b


class MyClass:
    @log()
    def add1(self, a: int, b: int) -> int:
        """Take two integers and return their sum."""
        return a + b

    @log
    def add2(self, a: int, b: int) -> int:
        """Take two integers and return their sum."""
        return a + b


if __name__ == '__main__':
    logging.basicConfig(level=logging.INFO)
    print(add1(1, 2))  # 3
    print(add2(1, 2))  # 3
    my_inst = MyClass()
    print(my_inst.add1(1, 2))  # 3
    print(my_inst.add2(1, 2))  # 3
INFO:root:wrapper is called, func=<function add1 at 0x0000029A770C6980>, args=(1, 2), kwargs={}
3
INFO:root:wrapper is called, func=<function add2 at 0x0000029A770C6660>, args=(1, 2), kwargs={}
3
INFO:root:wrapper is called, func=<function MyClass.add1 at 0x0000029A772A62A0>, args=(<__main__.MyClass object at 0x0000029A77167810>, 1, 2), kwargs={}
3
INFO:root:wrapper is called, func=<function MyClass.add2 at 0x0000029A772A6200>, args=(<__main__.MyClass object at 0x0000029A77167810>, 1, 2), kwargs={}
3

方法1較為直觀,以func是否為None來決定回傳值。

方法2

# 09
import logging
from functools import partial, wraps


def log(func=None, /, *, to_log=True):
    if func is None:
        return partial(log, to_log=to_log)

    @wraps(func)
    def wrapper(*args, **kwargs):
        if to_log:
            logging.info(f'wrapper is called, {func=}, {args=}, {kwargs=}')
        return func(*args, **kwargs)
    return wrapper


@log()
def add1(a: int, b: int) -> int:
    """Take two integers and return their sum."""
    return a + b


@log
def add2(a: int, b: int) -> int:
    """Take two integers and return their sum."""
    return a + b


class MyClass:
    @log()
    def add1(self, a: int, b: int) -> int:
        """Take two integers and return their sum."""
        return a + b

    @log
    def add2(self, a: int, b: int) -> int:
        """Take two integers and return their sum."""
        return a + b


if __name__ == '__main__':
    logging.basicConfig(level=logging.INFO)
    print(add1(1, 2))  # 3
    print(add2(1, 2))  # 3
    my_inst = MyClass()
    print(my_inst.add1(1, 2))  # 3
    print(my_inst.add2(1, 2))  # 3
INFO:root:wrapper is called, func=<function add1 at 0x000002607BBC67A0>, args=(1, 2), kwargs={}
3
INFO:root:wrapper is called, func=<function add2 at 0x000002607BBC4FE0>, args=(1, 2), kwargs={}
3
INFO:root:wrapper is called, func=<function MyClass.add1 at 0x000002607C0862A0>, args=(<__main__.MyClass object at 0x000002607BA98950>, 1, 2), kwargs={}
3
INFO:root:wrapper is called, func=<function MyClass.add2 at 0x000002607C0863E0>, args=(<__main__.MyClass object at 0x000002607BA98950>, 1, 2), kwargs={}
3

方法2的寫法非常優雅,且可以免去定義一層中間的function,但需要對decorator有較深的體會才容易運用自如(註5)。

當日筆記

  • 不論decorator包含了幾層function,我們的目的可以想作是返回一個wrapper function,其所接收的參數與返回值,會與被裝飾的function相同,而我們可以在過程中動點手腳,例如進行logging或驗證型別等。

當在多層function中迷路時,建議回到核心原理,以# 08的架構逐層往下思考。

@log(to_log=False)
def add(a: int, b: int) -> int:
    return a + b

相當於

add = log(to_log=False)(add)

相當於

add = dec(add) 

dec中內含to_log=False

相當於

add = wrapper

wrapper中內含to_log=Falsefuncadd,且functools.wraps會幫忙將addmetadata更新給wrapper

此時的add就是wrapper,所以add(1, 2)即相當於wrapper(1, 2)

  • functools.wraps可以快速將基本型態1轉為基本型態2
  • 常用型態 方法1直觀但比較繁瑣,方法2稍難理解但優雅。

備註

註1:由於decoratormeta programming的一種,所以我們可以返回任何obj。舉例來說,雖然很奇怪,但是我們是可以將args中每一項加1之後,再傳遞給func

# 101
from functools import wraps


def dec(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        args = (arg+1 for arg in args)
        return func(*args, **kwargs)
    return wrapper


@dec
def add(a: int, b: int) -> int:
    return a + b


if __name__ == '__main__':
    print(add(1, 2))  # 5

註2:functools.wraps會呼叫functools.update_wrapper來更新metadata,如果有除了預設的__module__, __name__, __qualname__, __doc__,__annotations____dict__attribute需要更新的話,必須自己呼叫functools.update_wrapper或是手動更新。

註3:如果您有很多參數或有類似jsondatabase等有schema需要驗證的情形,以Rust重新改寫的PydanticV2或許會是不錯的選擇。

註4:從Python3.10開始,isinstance可以接收多種Union Type,也就是說isinstance(1, type.Union[int, str])isinstance(1, int | str)或是# isinstance(1, (int, str))等,都是可接受的語法。

註5:對於想更深入研究# 09寫法的朋友,可以參考這篇文章
註6:*args**kwargstype hints

Code

本日程式碼傳送門


上一篇
[Day04] 初翼 - Tips:11~15
下一篇
[Day06] 次翼 - Decorator:@class to func
系列文
Python十翼:與未來的自己對話30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言